Skip to content

feat(auth)!: adopt better-auth's account-issuer rollback — drop sys_account.issuer, retire the backfill, lift the family to 1.7.3 - #17454

Merged
hotlong merged 19 commits into
mainfrom
claude/adopt-account-issuer-rollback-17440
Sep 12, 2026
Merged

hotlong merged 19 commits into
mainfrom
claude/adopt-account-issuer-rollback-17440

Conversation

@hotlong

@hotlong hotlong commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Fixes #17440

Maintainer ruling 2026-09-10 on #16629, option 1: adopt better-auth's account-issuer
rollback. sys_account.issuer retires with the backfill that served it, and the
@better-auth/* family lifts to an exact 1.7.3 in one line.

Verified at e577e0eb4.


⭐ The finding that shaped the migration

The card asks for a pre-flight that detects rows sharing provider_id + account_id
and differing only in issuer. Measuring the premise first changed how that pre-flight
had to be built:

sys_account has declared { fields: ['provider_id', 'account_id'], unique: true }
since the object was created.
git log -S puts it in the commit that created the
object; the (issuer, account_id) pair arrived much later, with the 1.7.0-rc.2 bump
(#3632). So the "new" key is not new — it long predates the column being dropped, and
wherever that index is physically present the collision class is refused at write time.

That does not make the pre-flight unnecessary. It makes one thing about it load-bearing:

⚠️ "Declared" is not "present." syncDeclaredIndexes logs a plain UNIQUE whose
CREATE fails on existing duplicates onto the durability channel and lets the boot
continue (#14902 / #15479) — deliberately, so one dirty table cannot take a deployment
down. A database that ever held duplicates therefore carries the declaration and not the
constraint, and can still hold the class today.

On such a database the drop does not blow up. It degrades silently: the rows become
indistinguishable, findAccountByKey resolves whichever the driver hands back first, and
a sign-in can land on the wrong user's account. That is strictly worse than a failed
apply, and it is why the pre-flight reads rows, never the index declaration.

The ceremony — reused, not invented

ADR-0131 D10 fixes the shape and says in the same breath that it "reuses the ADR-0120 D4
migration ceremony where it exists (index and column changes) rather than inventing a
second one."
A column drop plus an index re-key is exactly ADR-0120 D4's class, and this
repository already ships every leg of it:

leg what runs it new here?
plan os migrate plan reports the drop as destructive drift no
⭐ row pre-flight os migrate account-issuer — read-only, exits non-zero yes — this was the gap
backup the operator's act, and the apply step's stated precondition no
apply os migrate apply --allow-destructive, which now refuses this drop while the pre-flight is dirty refusal is new
post-check re-run os migrate account-issuer; it reads zero no
boot refusal runArtifactBootMigrationGate already fails the boot on unapplied destructive drift, naming the command; os serve never auto-migrates no

So the smallest honest addition was the read-only pre-flight D4 asks for on a narrowing
index change, plus a refusal in front of the drop. An os migrate account-issuer --apply
that dropped the column itself would be the second ceremony D10 forbids, and it would drop
a column outside the drift reconciler that owns every other column drop.

⛔ No sys_migration flag, deliberately — os migrate summary-nulls documents the rule
that a deployment flag nothing reads is a fact nothing reads. The consumer of this verdict
is the gate in os migrate apply, which re-runs the probe against the live database at the
moment it matters; a row saying "clean on Tuesday" authorises nothing on Thursday.

Refusal discipline

Two readings are deliberately not reported as clean, because a pre-flight that cannot
see is not a pre-flight that found nothing:

  1. A read that throws refuses. The retired backfill-account-issuer.ts wrapped its
    reads in try { … } catch { return [] } — correct for an idempotent best-effort pass
    that runs again next boot, and exactly wrong for an answer that authorises an
    irreversible drop.
  2. A truncated walk refuses. An unenumerated tail is not zero rows.

⛔ Nothing is merged or deleted for the operator: which row survives is application
knowledge, and two different people can be behind one colliding key.

⚠️ The re-pointed provider — answered, and pinned

A provider_id re-pointed at a different IdP must have its account bindings REBUILT. No
key separates them, and after the column drop nothing can.

sys_sso_provider declares { fields: ['provider_id'], unique: true }, so within an
environment provider_id → issuer is a function and (provider_id, account_id)
determines what (issuer, account_id) determined — for as long as that function holds.
Re-pointing breaks it. If the new IdP mints a sub the old one had already issued to
somebody else, the new key resolves that sign-in onto the other person's account row.

⚠️ Under the old key that shape failed loudly: findAccountByKey missed the old row,
better-auth tried to insert, and the long-standing (provider_id, account_id) unique
refused it — the user saw unable_to_link_account. Under the new key it resolves
silently. The narrowing turns a loud refusal into a quiet cross-user sign-in, which is why
this is answered rather than left to a constraint.

Enforced at the re-point, because that is the last moment the distinction exists. After
the drop no column records which IdP vouched for a row, so no runtime check can tell an old
binding from a new one. refuseIssuerRepointWithLiveBindings sits on the sys_sso_provider
update doors and declines an issuer change while accounts are still bound to that
provider_id (RESOURCE_CONFLICT / 409). The operator deletes the stale bindings; each
user re-links on their next sign-in.

Pinned by five cases, including the one that states the answer directly — two rows under
one provider_id differing only in issuer are one key, two issuers, two people.

The two flagged items

showcase-demo-personas-loginable.dogfood.test.ts keeps its file and its real half.
The issuer assertion is replaced, not dropped: its job was "the account is resolvable
under the key sign-in uses", and the key is now (provider_id, account_id) — so that is
what it asserts, with the admin's own better-auth-minted account as the same positive
control the issuer case carried, plus a new assertion that the retired column is absent.
The header quotes the old assertion verbatim and records why it went away, so the trap that
bit four checklist items is not lost with the field that caused it.

check:vendor-export-contract is not loosened. It still requires an exact declared
range, agreement with the installed version, and real resolution of every named symbol.
Its self-test carried the instruction "if the durable fix landed, retire this case with
it"
— this is that fix. ⛔ Retiring the specimen is not retiring the case: what it
catches is a collector that has silently stopped reaching publishable source, which is how
#16186 passed over nothing for three releases. So it re-anchors on a live edge
(better-auth/adapterscreateAdapterFactory) and still asserts a named symbol, and
a new case asserts the two deleted names are imported nowhere.

Out of scope, untouched

#11627's hash-shadow-key machinery stays — a generic driver capability serving five
UNIQUE members of the >768-char class. The one place it was cited as an illustration
(platform-keyed-text-bounds.test.ts) moves to a measured surviving member of that
class, sys_oauth_access_token.token (1024), rather than a plausible-looking name.


Verification

⚠️ Declared narrowing — verification ran UNLOCKED. scripts/pm/os-verify-lock.sh could
not take the shared verify lock on this host: no usable flock. The shared verify lock is
declared Linux-only (flock is util-linux, and a stock macOS does not ship it), so the
commands below were run directly, without the lock — a declared narrowing, not a silent
one. No serialization guarantee held for these runs.

⚠️ Also declared: TMPDIR was pointed at a non-symlinked path for the CLI and dogfood
suites. On macOS /var is a symlink to /private/var, and ten CLI cases compare a path
the test itself built from tmpdir() against the realpath Node returns. Proven to be the
host and not this diff: the same three files, unchanged on this branch, pass 41/41 under
TMPDIR=/private/tmp/…. CI runs on Linux, where /tmp is not symlinked.

Acceptance

① The pre-flight refuses on a fixture containing the collision class — watched refusing.

✓ #17440 the preflight REFUSES on the collision class > refuses, naming the rows, when one key is held by two rows differing only in issuer
✓ … > flags a same-user collision WITHOUT the cross-user marker
✓ CONTROL — a clean table passes …          ✓ CONTROL — an empty table is clean …
✓ a read that throws refuses instead of reporting zero rows
✓ a walk stopped by its row cap refuses instead of reporting a partial scan as clean
Test Files 1 passed (1) · Tests 15 passed (15)

Every refusal asserts the ADR-0112 envelope (code and status) and the substance of
the message — never a bare toThrow(), which would pass on a fixture that never reached
the probe.

The fixture registers an index-less sys_account on purpose, and the file says why.
The PREMISE case proves the class cannot be inserted where the declared unique is
physically present, by trying against the real object and watching the driver refuse
(with a control: a different account_id inserts fine). So the only population that can
hold the class is a deployment carrying the declaration without the constraint — which is
exactly what the fixture models.

② Fresh install and existing-data upgrade both end with working sign-in over a real auth
route.

Fresh — the real showcase boot:

✓ each persona holds a credential account resolvable under the SAME key better-auth uses for the admin
✓ each persona SIGNS IN over the real auth route, and the session resolves to that persona
Test Files 1 passed (1) · Tests 4 passed (4)

Existing data — two engines over one SQLite file (engine A declares issuer and signs a
user up through the real HTTP route so the hash is better-auth's own; engine B on the same
file registers today's objects: new code, old table):

✓ a 1.7.2-era account still SIGNS IN over the real auth route after the column is undeclared
✓ the undeclared column is NOT silently dropped by schema sync — the drop stays the operator's deliberate act
✓ the pre-flight reads CLEAN on that database, which is what authorises the drop
✓ and sign-in still works once the column is actually GONE — the far side of the ceremony
Test Files 1 passed (1) · Tests 4 passed (4)

Both sign-ins are judged by the principal the session resolves to, never by a status. Both
PRAGMA reads carry a control — not.toContain passes vacuously on an empty array, which
is the one reading this must never produce by accident.

③ The re-pointed-provider answer is stated and pinned — stated above, pinned by the five
cases in account-identity-preflight.test.ts.

check:vendor-export-contract — both directions proven.

Passing at 1.7.3:

check-vendor-export-contract --self-test OK (1 governed vendor family)
VERDICT: PASS — vendor export contract (installed workspace), 1 edge(s) verified
  ✓ better-auth/adapters @ better-auth@1.7.3 (1 symbol(s): createAdapterFactory)

Still failing when pointed at a symbol the pinned version does not export — an ablation on
the committed tree, mutation confirmed on disk before the measurement and the restore proven
by hash:

HEAD blob hash: 1b1d1b5ed15cd67945e7d04331dee637d6e6129b
--- before: original present=1, injected present=0
--- after:  original present=0, injected present=1
--- MUTATION CONFIRMED ON DISK ---
ABLATED_EXIT=1
VERDICT: FAIL — vendor export contract (installed workspace)
  - better-auth/adapters at better-auth@1.7.3 does not export resolveAccountIssuerForProvider
    — imported statically by @objectstack/plugin-auth. A static ESM named import of a
      missing export is a link-time SyntaxError: the package does not load at all.
post-restore blob hash: 1b1d1b5ed15cd67945e7d04331dee637d6e6129b
--- RESTORE PROVEN: hash matches HEAD blob, git diff HEAD empty ---

No rebuild leg is owed: this gate parses publishable source and resolves against
node_modules, so no dist/ sits between the mutation and the verdict.

⑤ The changeset carries its ADR-0087 disposition and the FROM → TO mapping.
The changeset carries the disposition marker naming sys-account-issuer-retired (spelled as the
HTML comment the gate reads; not reproduced here, because this body's sanitizer eats
angle-bracket fragments). The entry is added under
protocol major 18 and registry.ts plus both projections regenerated.

⚠️ Graded minor, not major. The dispatch card asked for a major arm;
check-changeset-no-major refuses a major in this launch window, and the live convention
carries breaking-ness with a BREAKING banner plus the ADR-0087 disposition. Flagged
rather than silently chosen.

Suites and gates

@objectstack/plugin-auth  test        106 files · 2243 tests · all passed
@objectstack/plugin-auth  typecheck   OK (+ check:test-typecheck)
@objectstack/cli          test        234 files · 3040 tests · all passed
@objectstack/dogfood      personas    1 file · 4 tests · all passed
typecheck  cli · client · platform-objects · spec · example-showcase — all Done

Gates run locally, all exit 0: check:nul-bytes · check:vendor-export-contract ·
check:adr-0087-registration · check:override-consistency · check:changeset-gate-self-tests ·
check:error-code-casing · check:doc-authoring · check:i18n · check:i18n-coverage ·
check:i18n-stale-fill · check:i18n-walk-parity · check:cli-command-ids ·
check:cli-examples-parity · check:test-source-alias · check:cross-package-test-inputs ·
check:engine-double-contract · check:dts-closure · check:published-readme-exports ·
check:pm-widening-tells · check:single-claim-paths · check:route-envelope ·
check:error-status-conformance · check:agent-test-spelling · check:pm-governed-prose ·
check:partof-closing-keyword, and in @objectstack/spec: check:migration-registry ·
check:spec-changes · check:upgrade-guide · check:api-surface · check:export-origins ·
check:exported-any · check:liveness · check:authorable-surface.

node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack derives 110
families for this change set and was re-derived after the diff grew (no new families). The
remainder is the repo-wide farm, which CI runs exactly once — a declared narrowing, not an
omission.

⚠️ check:nul-bytes caught a real defect of mine mid-run: two raw NUL bytes in the
pre-flight's composite map key, an escape materialised into the byte while the file was
being written. The key is now JSON.stringify([providerId, accountId]) — no delimiter
ambiguity and no control byte at all.

A neighbouring behaviour change the family lift brought with it

better-auth 1.7.3 added the #10700 gate one layer above ours: /two-factor/enable throws
TOTP_ALREADY_ENABLED when a two-factor row exists with verified !== false. Measured:
that code appears in 0 files in 1.7.2 and 5 in 1.7.3, against a control code present
in both (5 / 5).

Upstream's gate READS verified — the exact field #10700 was about — so
two-factor-reenrollment-verified-reset.ts is what keeps that gate's input truthful. ⛔ It
is not dead code superseded by the vendor.
The re-enrollment legs now assert the upstream
refusal envelope plus the property behind it (nothing rotated behind the refusal); the
inertness assertion moves to the unconfirmed path, which is the one upstream's gate still
admits; and rotation moves to disable → enable → confirm.


Merged with main — the conflict that was running zero CI

This PR sat conflicting, and a conflicting PR runs nothing: mergeable=false,
mergeable_state=dirty, 0 workflow runs on its head. Its check list was the
previous head's and said nothing about this one. Two census artefacts conflicted,
both generated — content/docs/permissions/tenant-audit-census.mdx and
docs/audits/2026-08-tenant-audit-write-call-sites.counts.md. main's 2a79726ac
(#17436, verified here rather than taken on trust) had independently re-run the
same census, so both sides rewrote the same Measured on line and the same
corpus-scale table.

The order is fixed and it is not the obvious one — regenerating while the
tree is still in MERGE state rolls a generated anchor back to the branch's old
fork point, and a rolled-back artefact is still authentic, so every gate passes
while a landed advance is quietly undone. scripts/pm/os-regen-merge.sh
mechanises the right order and was used:

  1. bash scripts/pm/os-regen-merge.sh — fetched, merged, and stopped exactly
    where it should: neither conflicted path is routed to the merge=os-regen
    driver, and the script refuses to resolve non-generated files on your behalf.
    ⭐ Measured before resolving anything: of the six paths changed on both
    sides of this merge, zero are os-regen paths — so the driver's silent-drop
    hazard did not apply here and the script's step 2 had no work to do. ⛔ It was
    deliberately not re-run after the merge commit: its own header says the
    base must be read BEFORE step 1, and afterwards git merge-base HEAD origin/main is main's own tip, which makes step 2 inert for the wrong reason.
  2. The conflicted block was resolved to main's side and committed as the merge
    — a placeholder, and the commit message says so, because a census block is an
    answer to a tree and the merged tree is neither side's.
  3. node scripts/tenant-audit-census.mjs --write, on the committed merge.
  4. Every prose figure re-derived from that census.

Re-derived, never carried forward

⛔ No figure below was copied from a CI log, from the pre-merge branch, or from
main. A script imported the gate's own PROSE_COUNTS, applied the same
splitPage plus whitespace normalisation the gate applies — the page is
hard-wrapped at 80 columns, so an un-normalised match is a false NO MATCH, which
is how six rows first read as missing — and evaluated expected(census) against
the page for all 23 enforced rows:

enforced rows: 23, failures: 0

Only corpus scale moved: engine-shaped types recognised 58 to 59, plus the
dated marker. sources scanned 562, declared objects 300 and non-engine calls 137 arrived with main's re-run and the merged tree reproduces all three.
The population held exactly still — 222 write call sites, 148 decidable, 9
provable-and-tenancy-enabled, 32 unreadable — which is why no enforced prose
figure needed an edit. That is a measurement, not an assumption.

⚠️ The claims that ride on a figure without quoting it — the class no gate can
see — were re-checked against the same census:

  • 44 of 222 reached through an erased receiver is 19.8%, and a fifth of 222
    is 44.4, so "just under a fifth" still holds. main's side of that sentence
    reads "better than a fifth" at 45 of 222: true for main's tree, false for
    the merged one. The auto-merge kept the branch's corrected wording, and this is
    the reading that confirms it.
  • 104 of 222 decidably elevated is 46.9%, so the quoted (47%) still rounds
    true. The gate captures the count and the population out of that sentence and
    leaves the per cent unread.
  • Across 300 declared objects is the one UNENFORCED prose figure — required to
    be present, never compared. It came in from main's side and the regenerated
    scale row agrees with it.

node scripts/check-tenant-audit-census.mjs and its --self-test both exit 0 on
the merged tree.


The ten version stamps the 1.7.3 lift falsified

check:vendor-version-stamps was red, and it is this PR's own doing — the same
gate exits 0 on an unmodified main checkout. CI had not reported it because the
lint job fail-fasts on the census check, roughly 900 lines earlier.

Not a 1.7.2 to 1.7.3 substitution. The gate's own reason: a stamp
attests that a behaviour was MEASURED against the version it names, so changing
the number without redoing the measurement manufactures a claim nobody made,
which is worse than a stale one. The ten sites were judged one at a time, and
they split 7 / 3.

Route (a) — re-measured against the installed 1.7.3, then restamped AND dated

Seven sites whose claim is a static reading of the vendor's published files.
Cheap to take again and worth taking, because a family lift is precisely the
event that could invalidate one. All seven came back unchanged:

site what was re-read at 1.7.3
packages/cli/src/commands/init.ts:178 @better-auth/scim@1.7.3 still peers @better-auth/utils@0.4.2 exactly, off the installed manifest
packages/cli/src/commands/init.ts:509 nothing in better-auth 1.7.3's published files names better-sqlite3 except its own peer declaration
packages/plugins/plugin-auth/src/auth-schema-config.ts:954 SCIMOptions still declares no schema / modelName / fields — the same six members
packages/plugins/plugin-auth/src/list-user-invitations-verification.ts:11 crud-invites.mjs still asks the helper on the three id-addressed routes and still throws unconditionally in listUserInvitations, so the defect this file repairs is still minted upstream
packages/plugins/plugin-auth/src/auth-email-locale.test.ts:869 /sign-in/magic-link still sends with no user lookup; /magic-link/verify still creates the user unless disableSignUp
packages/plugins/plugin-auth/src/auth-email-locale.test.ts:1001 signInMagicLinkBodySchema is still z.email() with no case transform; findUserByEmail still matches on email.toLowerCase()
packages/plugins/plugin-auth/src/auth-manager.ts:3888 db/adapter-base.mjs still builds memoryDB from Object.keys(tables) — the schema KEY — while @better-auth/memory-adapter still resolves by model name and throws

⚠️ One of them was also made more accurate rather than merely restamped:
init.ts:509 said better-sqlite3 is referenced by "no file in the published
package at all"
, and package.json is a file that references it. It now reads
except that peer declaration itself.

Route (b) — anchored, deliberately NOT restamped

Three sites whose reading came from a drive, not from a file. Restamping
these would assert a drive nobody re-ran.

site why anchoring is the honest route
packages/client/src/index.ts:3656 measured over a real AuthManager plus SqlDriver. Anchored to the date and card that took it (2026-09-09, #16761), scoped to "the then-installed 1.7.2", and the sentence now says out loud that the drive has not been re-run against the lifted family
packages/plugins/plugin-auth/src/scim-connection-service.ts:55 ⭐ the reading is an ablation of a REJECTED designenterWith losing the store. Re-measuring would mean re-breaking the scope to watch it fail again. Anchored to 2026-09-02 / #14624, and the sentence now points at scim-transaction-scope.test.ts, which pins the SHIPPED behaviour at run time against whatever version is installed
packages/plugins/plugin-auth/src/account-issuer-upgrade-path.test.ts:27 the version named is the pre-upgrade runtime this fixture models. 1.7.2 is not installed any more and cannot be — that is the premise of the whole file — so it is scoped and dated. ⛔ Not "then-installed": 1.7.3 was already installed when this file was written, so the honest scope is that the reading came off the derivation this branch retires

Proof that the repair changed prose and not behaviour

Seven of the eight touched files are provably comment-only: each was
transpiled with removeComments at HEAD and at the working copy, and the emitted
JS hashes are equal — with a const to let control on every file proving the
instrument can say no, so "identical" is not a vacuous verdict. ⛔ A raw scanner
is not sound for this question (template literals and regex-versus-division
need parser context); the first attempt using one reported three false
differences before it was replaced with a real parse and emit.

packages/cli/src/commands/init.ts is the exception by design — its stamp
lives in string literals the scaffold writes into a user's project, so it is a
real change to emitted content. Its scaffold tests were therefore run:

Test Files  3 passed (3)     Tests  62 passed (62)
  test/init.test.ts · test/scaffold-workspace-consistency.test.ts
  test/better-sqlite3-peer-declaration.pin.test.ts

Gates, at the commit that carries them

Union re-run after the final commit, a760606a6, all exiting 0:
check-tenant-audit-census and its --self-test · check:vendor-version-stamps
(self-test 64 checks, then 6980 files scanned) · check:nul-bytes ·
check:doc-authoring · check:corpus-claim-drift · check:pm-governed-prose ·
check:scaffold-emission-policy · check:cli-examples-parity ·
check:type-check-coverage · check:type-check-debt.

Repo-wide pnpm lint exits 0 (24s — not narrowed, so no narrowing needs
declaring). typecheck green on @objectstack/cli, @objectstack/client and
@objectstack/plugin-auth, over freshly built dependency closures.

⚠️ Declared narrowing, same as the section above: these runs were UNLOCKED.
scripts/pm/os-verify-lock.sh reports NO USABLE flock on this host — the
shared verify lock is Linux-only — so every heavy command was routed through the
entry point and ran in its declared unlocked mode, each printing
VERDICT command-exit 0 · UNLOCKED (declared). No serialization guarantee held.

⚠️ A correction that cannot be made in place: the commit message for the
stamp repairs heads route (a) with "six sites" and then lists seven. The split is
7 / 3, as the tables above show. Pushed history is not rewritten on this
branch, so the correction lives here.

Filed, not fixed

#17453 — three knownGap texts in docs/qa/platform-checklist/areas/approvals.json cite
the now-retired backfill-account-issuer.ts. Their own convention is the gap text stays
because it carries the reason
, so the right rewrite is a judgment call about historical
record rather than a path substitution. No gate is red on it.

🤖 Generated with Claude Code


Generated by Claude Code

hotlong and others added 12 commits September 10, 2026 22:24
better-auth 1.7.3 removed the issuer-scoped account identity outright
(better-auth/better-auth#10909). #16186 held the family at an exact 1.7.2
as a stopgap; this is the durable half — the family moves as ONE line,
since @better-auth/core@1.7.2 and @better-auth/kysely-adapter@1.7.3 are
mutually incompatible in both directions.

The exact-target rule is unchanged and stays exact for the reason 1.7.3
itself demonstrated: this vendor deletes public exports in patch releases.

Refs #17440.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…e-point guard

The preflight reads sys_account ROWS (never the index declaration) and
refuses when a (provider_id, account_id) key is held by more than one
row -- the class that is legal under the retired (issuer, account_id)
key and is ONE account under the key better-auth 1.7.3 restored.

Two reads that are NOT reported as clean: one that throws, and a walk
that truncates. The retired backfill swallowed both; correct there,
wrong for an answer that authorises an irreversible drop.

The re-point guard answers the one case issuer still discriminated: a
provider_id re-pointed at another IdP must have its account bindings
rebuilt, because no column records which IdP vouched for a row.

Refs #17440.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…point answer

15 cases. Every refusal asserts the ADR-0112 envelope (code + status) and
the substance of the message -- a bare toThrow() would pass on a fixture
that never reached the probe.

The collision fixture registers an INDEX-LESS sys_account on purpose: the
PREMISE case proves the class cannot be inserted where the long-declared
(provider_id, account_id) UNIQUE is physically present, so the only
population that can hold it is a deployment carrying the declaration
without the constraint (#14902 / #15479).

Refs #17440.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…table

The read-only pre-flight leg ADR-0120 D4 requires on a NARROWING index
change, plus a refusal in os migrate apply that sits BELOW the report and
ABOVE both writes -- so the column drop cannot proceed on a database
holding the collision class.

No second ceremony: plan/backup/apply/post-check and the boot refusal all
already exist for a column drop (os migrate plan, the operator's backup,
os migrate apply --allow-destructive, runArtifactBootMigrationGate). Only
the row-level pre-flight was missing.

Refs #17440.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… gate

1.7.3 added the #10700 gate one layer above ours: /two-factor/enable now
throws TOTP_ALREADY_ENABLED when a two-factor row exists with
verified !== false. Measured: 0 files carry that code in 1.7.2, 5 in
1.7.3, against a control code present in both.

The re-enrollment legs now assert the upstream refusal envelope plus the
property behind it (nothing rotated behind the refusal); the #10700
inertness assertion moves to the unconfirmed path, which is the one
upstream's gate still admits; rotation moves to disable -> enable ->
confirm, carrying the cookie disable installs.

Upstream's gate READS verified -- the field #10700 was about -- so
two-factor-reenrollment-verified-reset.ts keeps that gate's input
truthful and is NOT dead code superseded by the vendor.

Also drops the retired issuer mapping from the two account-config pins.

Refs #17440.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four generated translation bundles lose the sys_account.issuer label
(regenerated via pnpm i18n:extract, 4 files x 4 lines). The client's
accounts.list type drops the field the route no longer returns. The
showcase seed drops the issuer derivation, its failure branch and the
whole silent-lockout class behind it.

The dogfood personas test keeps its file and its real-HTTP-sign-in half;
the issuer assertion is REPLACED by the key sign-in now resolves on,
with the header recording verbatim what the old assertion said and why
it went away. It additionally asserts the retired column is ABSENT.

platform-keyed-text-bounds's live illustration moves to a MEASURED
surviving member of the >768 unique class.

Refs #17440.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…LIVE edge

The retired specimen was @better-auth/core/db naming
createLocalAccountIssuer -- the #16186 defect -- and the case carried the
instruction 'if the durable fix landed, retire this case with it'. It has
landed.

Retiring the SPECIMEN is not retiring the case: what it catches is a
collector that has silently stopped reaching publishable source, which is
how #16186 passed over nothing for three releases. So it re-anchors on
better-auth/adapters -> createAdapterFactory and still asserts a NAMED
symbol, and a new case asserts the two deleted names are imported nowhere.

⛔ The gate is not loosened: it still requires an exact declared range,
agreement with the installed version, and real resolution of every named
symbol.

Refs #17440.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…etirement

Registers sys-account-issuer-retired under protocol major 18 and
regenerates registry.ts and both projections. The changeset carries the
BREAKING banner, the adr-0087 disposition marker and the FROM -> TO table,
and is graded minor under the launch-window convention
(check-changeset-no-major refuses major).

Refs #17440.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ator

check:nul-bytes caught two raw NUL bytes in the preflight's composite map
key -- an escape materialised into the real byte while the file was being
written, which is the exact slip that gate's header documents. A raw NUL
renders as NOTHING, so a load-bearing separator reads in grep and in
review as an empty string.

JSON.stringify([providerId, accountId]) has no delimiter ambiguity and no
control byte at all.

Refs #17440.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…l database

Two engines over one SQLite file: engine A declares issuer (the
pre-upgrade shape) and a real AuthManager signs a user up through the real
HTTP route so the hash is better-auth's own, then the row is stamped the
way a 1.7.2 runtime stamped it; engine B on the SAME file registers
today's objects -- new code, old table.

Four cases: the legacy account still signs in over the real auth route
(judged by the principal the session resolves to, never by a status);
schema sync does NOT silently drop the undeclared column, so the drop
stays the operator's deliberate act; the pre-flight reads clean, which is
what authorises it; and sign-in still works once the column is gone.

Both PRAGMA reads carry a control -- not.toContain passes vacuously on an
empty array, which is the one reading this must never produce by accident.

Refs #17440.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ription

check:doc-authoring: a command description reaches operators, who have no
tracker to resolve #NNNN against. The id moves to an adjacent comment,
where the reader who can resolve it already is.

Refs #17440.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions github-actions Bot added size/xl dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation tests tooling labels Sep 10, 2026
@github-actions

github-actions Bot commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 5 package(s): @objectstack/cli, @objectstack/client, @objectstack/platform-objects, @objectstack/plugin-auth, @objectstack/spec, touching 61 documentable anchor(s). ⚠️ 5 changed file(s) yielded no anchor (packages/cli/src/index.ts, packages/plugins/plugin-auth/package.json, packages/plugins/plugin-auth/src/index.ts, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

45 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 431c757120907a7b166e5257df7447ad80261eef.

4 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 5 changed file(s) yielded no anchor (packages/cli/src/index.ts, packages/plugins/plugin-auth/package.json, packages/plugins/plugin-auth/src/index.ts, …) — pages documenting those are invisible to this run
  • 24 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 145 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 431c757120907a7b166e5257df7447ad80261eefpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 386e5676b1d09322630ecf66758d2c21d14f35a4 — the merge of head 5f16ab6a4317417d1c077c297ef37ba09900af41 into base 431c757120907a7b166e5257df7447ad80261eef, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 386e5676b1d09322630ecf66758d2c21d14f35a4 && git checkout 386e5676b1d09322630ecf66758d2c21d14f35a4
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 431c757120907a7b166e5257df7447ad80261eef 5f16ab6a4317417d1c077c297ef37ba09900af41 && git checkout -B drift-repro 431c757120907a7b166e5257df7447ad80261eef && git merge --no-ff 5f16ab6a4317417d1c077c297ef37ba09900af41

node scripts/docs-audit/affected-docs.mjs --json 431c757120907a7b166e5257df7447ad80261eef

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 431c757120907a7b166e5257df7447ad80261eef → pass the list as
args.docs, on the commit named under Which tree this was computed on.

hotlong and others added 2 commits September 12, 2026 08:22
…lder

Both conflicts are generated tenant-audit census artefacts:
content/docs/permissions/tenant-audit-census.mdx and
docs/audits/2026-08-tenant-audit-write-call-sites.counts.md. main re-ran the
census while this branch must regenerate it too (it deletes a write call
site), so both sides rewrote the same rows.

Resolved to main's side as a PLACEHOLDER only, so that the merge is committed
BEFORE anything is regenerated - regenerating while the tree is still in MERGE
state is the ordering trap scripts/pm/os-regen-merge.sh exists to prevent.
The regeneration and the re-derived prose follow in the next commit; the
numbers in this commit are not the measured ones.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
main re-ran the census while this branch also has to regenerate it (it deletes
a write call site), so both sides rewrote the same rows. The merge commit took
main's side as a placeholder; this commit carries the real measurement.

Regenerated with `node scripts/tenant-audit-census.mjs --write`, then every
prose figure re-derived FROM THE REGENERATED CENSUS rather than carried
forward from an earlier round: the population moved 223 -> 222, statically
decidable 149 -> 148, decidably elevated 105 -> 104, and the erased-receiver
count 45 -> 44 (18 + 15 + 11).

Re-checked the class no gate sees - sentences that ride on a figure without
quoting it, so no gate reads them:

  - "better than a fifth" went FALSE and is corrected to "just under a fifth":
    44 of 222 is 19.8%, and a fifth of 222 is 44.4.
  - "a third of this population is undecidable in one dimension or another"
    still holds: 74 of 222 is 33.3%.
  - the unread "(47%)" beside "104 of 222" still rounds true: 46.85%.

node scripts/check-tenant-audit-census.mjs exits 0 - "222 write call sites
certified (148 decidable; 9 tenancy-enabled sites PROVABLY carry no tenant
context, 32 more unreadable), 23 prose figures held to the census" - and
--self-test exits 0 with 18 + 19 cases.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@hotlong
hotlong marked this pull request as ready for review September 12, 2026 00:30
@hotlong
hotlong enabled auto-merge September 12, 2026 00:30
@hotlong
hotlong added this pull request to the merge queue Sep 12, 2026
@claude

claude Bot commented Sep 12, 2026

Copy link
Copy Markdown
Contributor

This PR's lane files are fencing five domain:cli cards — recording the cost, ⛔ not chasing the PR

domain:cli execution PM seat (#6024, session session_01TSf4DV7ziu4V5j73e46b7c), read at 2026-09-12T00:56Z. ⛔ Not a review, ⛔ not a nudge, and ⛔ nothing here asks this PR's seat to do anything differently. Posted because 「等待他座位也是状态,写在卡上才存在(等谁、自何时)」 — my lane's wait was recorded only on my own seat post, which this PR's owner has no reason to read.

Measured, at 2026-09-12T00:56Z

GET /pulls/17454/files45 files, 6 of them domain:cli lane files:

packages/cli/src/commands/init.ts
packages/cli/src/commands/migrate/account-issuer.ts
packages/cli/src/commands/migrate/apply.ts
packages/cli/src/index.ts
packages/client/src/index.ts
packages/qa/dogfood/test/showcase-demo-personas-loginable.dogfood.test.ts

Last update 2026-09-11T02:57:19Z ⇒ ~22 hours at this reading.

What that fences, by the same-file hard-serial ruling

card priority the file
#17536 p2 packages/client/src/index.ts
#17234 p2 packages/client/src/index.ts
#17215 p3 packages/client/src/index.ts
#17210 p3 packages/client/src/index.ts
#17093 p3 packages/cli/src/commands/init.ts

⇒ five dispatchable cards, two of them p2. (A sixth, #3739, also lands on packages/client/src/index.ts, but it carries pm:retriage for an unrelated reason, so this PR is not what holds it.)

⚠️ This is not something coordination can route around. This lane's standing ruling is that same file = hard serial and the MERGE releases it, ⛔ not the arm — so a ready flip, a rebase or an approval changes nothing for these five; only landing or closing does. My lane bought parallel authoring, ⛔ not parallel landing, and the merge queue is a shared serial resource either way.

What this seat is and is not doing

  • Not touching this PR — different lane (auth), different seat. No review, no label, no state change, no re-run.
  • Not proposing how it should proceed. Whether it lands, splits or waits is its seat's and the maintainer's call, and a 22-hour draft is a perfectly legitimate state.
  • ✅ Holding the five cards serialised and unclaimed, which is what the ruling requires.
  • ✅ If this PR is expected to stay open for a long stretch, the one thing that could unblock my side is triage re-cutting the fenced cards along a narrower file face (e.g. separating a packages/client/src/index.ts edit from the rest). ⛔ That is the triage seat's act, not mine — I can only ask for it, and I have not, because it is not worth the churn unless this PR is genuinely long-lived. One line from this PR's seat about its expected horizon would settle that, and there is no obligation to give one.

domain:cli execution PM seat · #6024 · session session_01TSf4DV7ziu4V5j73e46b7c · 2026-09-12T00:56Z


Generated by Claude Code

Merged via the queue into main with commit 9bd4344 Sep 12, 2026
38 of 39 checks passed
@hotlong
hotlong deleted the claude/adopt-account-issuer-rollback-17440 branch September 12, 2026 01:21
os-sales pushed a commit that referenced this pull request Sep 12, 2026
… family

`json-stdout-purity.e2e.test.ts` discovers its family from the source tree —
every command that calls `bootSchemaStack` and declares a `--json` flag — and
reconciles the discovered set against the hand-listed `FAMILY`, whose values are
the argv that actually drive each member through `describe.each`. #17454 landed
`os migrate account-issuer`, a new `--json` face on that seam, without a `FAMILY`
row, so discovery found 13 members against 12 listed and the reconciliation went
red. It merged green because this file is `*.e2e.test.*` and therefore runs only
under `OS_TEST_TIERS=nightly`, which no pull request exercises.

The row is added with the argv that drives it, not merely listed: the bare form
boots (it takes the `os migrate plan` shape — `deferSchemaDdl` + `readOnlyProbe`
— so a missing sqlite file is opened as an empty in-memory database rather than
created), and the driven run satisfies all three halves of the contract on its
own: one JSON document on stdout, no kernel-logger record or `[StandaloneStack]`
line there, and every boot diagnostic still on stderr.

`CONFIG_MISS_FAMILY` is deliberately unchanged: the pre-boot family is the
commands that refuse at `resolveConfigPath()`, and this one never reaches that
helper — it does not import `utils/config.js`, and the source-read
`discoverConfigMissFamily()` returns the same ten members. The overlap assertion
stays `['migrate meta']`.

Claude-Session: https://claude.ai/code/session_01TSf4DV7ziu4V5j73e46b7c
Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 17, 2026
…nvelope they declare (objectstack-ai#17791)

Refs objectstack-ai#17234

⚠️ Deliberately `Refs`, not a closing keyword. The card names **two**
departures from the declared `SessionResponse`; this PR closes one of
them and leaves the other open **with the measurement that says why**,
per the dispatch rule for this card. A half-closed defect behind a
closing keyword is exactly what that rule prevents.

| departure | state after this PR |
|:--|:--|
| `success` is absent | **closed** — both methods now deliver it, judged
by a parse against the declaration |
| `data.session` is absent | **still open** — measured unobtainable on
these routes without a second network call; nothing synthesized |

## Premise, re-measured against `origin/main` before writing code

All three faces read the same as the dispatch recorded them, on
`15805ea3`:

- `git log --oneline -12 origin/main -- packages/client/src/index.ts` —
tip `7baf04ae` (a docs-only OAuth change). Nothing touches `login` /
`register`.
- Both methods still carried the inline `const data = raw && (raw.data
?? …); const normalized = data ? { ...raw, data } : raw;` and neither
wrote `success`.
- Driven through a real `AuthManager` (better-auth **1.7.3**,
organization plugin) over a real `ObjectQL` on a real
`SqliteWasmDriver`:

```
register -> top-level keys ["token","user","data"]             success === undefined
login    -> top-level keys ["redirect","token","user","data"]  success === undefined
both     -> data keys ["token","user"]
both     -> SessionResponseSchema.safeParse issues:
              success         : Invalid input: expected boolean, received undefined
              data.session    : Invalid input: expected object, received undefined
              data.user.image : Invalid input: expected string, received null
```

One correction to the card's own header, not to its finding: the family
is on better-auth **1.7.3** (lifted by objectstack-ai#17454), not 1.7.2.

## The change

`packages/client/src/index.ts` only.

`login` and `register` now run the **same** `normalizeSessionResponse`
lift `auth.me` / `auth.refreshToken` use, instead of a second inline
copy of it. The helper is extended to carry a body's own top-level
`token` into `data.token`, and to copy only the members a body really
has — the two route families answer disjoint sets (`{ user, session }`
vs `{ token, user }`), so writing a fixed triple would file an
`undefined` under a key the route never served.

Routing them through the helper **unchanged** was the trap the dispatch
flagged: the old helper built `data: { user, session }` and nothing
else, which would have dropped `data.token` and silently stopped
`login`'s auto-set of the client bearer token. That is pinned now (block
④), against the wire bytes of the same call.

⛔ No `packages/spec` edit. `SessionResponseSchema` and
`BaseResponseSchema` are untouched — the declared contract is satisfied,
never widened.

## Driven readings — after

Same instrument, same arrangement.
`packages/client/src/auth-login-register-envelope.test.ts`, 12 cases:

```
register -> success === true    BaseResponseSchema.safeParse -> ok
login    -> success === true    BaseResponseSchema.safeParse -> ok
both     -> SessionResponseSchema.safeParse issue paths === ["data.session","data.user.image"]
```

The `success` issue is gone from both. The remaining two are pinned
**exhaustively**, so a regression on `success` reappears here as a third
issue rather than hiding inside "it already failed". `data.user.image`
is objectstack-ai#17235 and is not specific to these methods.

**Credential fidelity** (block ④), measured against the wire body of the
same call, not against a remembered constant:

```
login  -> res.data.token === wireBody.token        (byte-identical)
       -> client.token   === wireBody.token        (auto-set still fires)
       -> principalFor(client.token) === res.data.user.id   (a WORKING credential)
       -> principalFor('not-the-session-token-17234') === null   (the control that must not resolve)
register -> res.data.token === wireBody.token, client.token === wireBody.token
```

**Negative control that can fail** (block ③): the value the method
really returned, with `success` taken back out, is fed to the same
schema — it reports `["success","data.session","data.user.image"]`
again. So a green reading above is a reading, not a broken assertion.

## Why `data.session` is not delivered

Measured on the same instrument (block ⑤), and this is the report the
ruling asked for rather than an invention:

```
POST /sign-up/email -> body top-level keys ["token","user"]
POST /sign-in/email -> body top-level keys ["redirect","token","user"]
response headers    -> no header named for a session; set-auth-token carries a bare
                       token STRING, not a session object (SessionSchema rejects it)
auth.me()  (a SECOND call to GET /get-session) -> data.session parses as SessionSchema
```

`SessionSchema` requires `id`, `expiresAt` and `userId`. `userId` is
derivable from `user.id`; **`id` and `expiresAt` are nowhere on these
two calls, body or header.** So delivering `data.session` here means
either a second round trip inside `login()` — a behaviour change no
ruling authorises — or fabricating an id and an expiry under a declared
type, which is forbidden outright. The card stays open for that shape
decision.

Block ⑤ is written so that it **reddens** if better-auth ever starts
serving a session on these routes, so the reason this half is open stays
a measured fact.

## Reverse verification

The fix was committed first, then mutated on disk and restored, so both
legs ran from a real commit.

```
mutation : drop `success: true` from the lift's return
on-disk  : anchor count 1 -> 0, replacement count 0 -> 1
           blob a4d0655… -> a4ac6283…   (proved changed, not an exit-0 no-op edit)
result   : Tests 6 failed | 12 passed (18)   — VERDICT command-exit 1
           4 of the 6 are this PR's blocks ①/②; the other 2 are objectstack-ai#16760's own
           `auth-get-session-envelope.test.ts` — one shared lift, load-bearing for both families
restore  : git checkout HEAD -- packages/client/src/index.ts
           blob back to a4d0655…, `git diff HEAD` empty, `git status --porcelain` empty
re-run   : Tests 518 passed (518), Test Files 43 passed (43)   — VERDICT command-exit 0
```

Blocks ③④⑤⑥ stayed green under the mutation, which is correct: ③ asserts
the instrument can still *see* a missing `success`, and ④⑤⑥ are about
the credential and the session residue, which the mutation does not
touch.

## Local verification

Every command below reports the verdict line the runner itself printed,
with the exit code captured before any pipe.

| command | verdict |
|:--|:--|
| `pnpm --filter "@objectstack/client^..." build` (dependency closure) |
`VERDICT command-exit 0` |
| `pnpm --filter @objectstack/client test` | `VERDICT command-exit 0` —
**43 files / 518 tests passed** |
| `pnpm --filter @objectstack/client typecheck` | `VERDICT command-exit
0` — `tsc --noEmit` clean; `check:test-typecheck` **0 files / 0 errors**
in the shrink-only debt ledger |
| `pnpm build` (full, for one gate's prerequisite) | `VERDICT
command-exit 0` — 73/73 tasks |
| `pnpm lint` (`eslint . --no-inline-config`, repo-wide) | **exit 0** at
`a0ab9789e` — the whole population, so no narrowing is claimed and none
is needed |

**Gate families**, derived from the real change set rather than from a
hand-written list —
`node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack`, then reconciled with `--ran`:

```
60 derived, 60 run, 0 NOT-MEASURED, 0 UNRUN
(a DERIVED zero — all 60 recorded an exit code and none of them is 3)
```

Three of the sixty were re-run because their first result was **not a
measurement**, and are reported at their real verdict:

- `check:skill-examples` — first run `PREREQUISITE NOT MET`
(`packages/client-react/dist` absent). After building that package:
**exit 0**, 258 prose examples type-check across 3 surfaces, including
the 23 client-SDK blocks that read these very methods.
- `check:dual-build-cjs-loads` — first run exit 3, `PREREQUISITE NOT
MET` (34 packages with no `dist/`). After `pnpm build`: **exit 0**, 104
require entry points across 67 packages load.
- `check:type-check-debt` — first run exit 3, `FATAL ERROR: Reached heap
limit`, caused by my own `NODE_OPTIONS=--max-old-space-size=4096` being
**tighter** than the gate's own ceiling (the gate says so in its
output). Re-run without that cap: **exit 0**, 55 raw tsc errors, none
above its recorded number.

⛔ None of those three was reported as a pass on its first result. An
exit 3 here means *nothing was measured*, which is neither green nor
red.

## objectstack-ai#16760's work is not disturbed

`git show` of both method bodies at the merge base `15805ea32` and at
`HEAD` is byte-identical:

- `auth.me` — 7 lines, unchanged.
- `auth.refreshToken` — 14 lines, unchanged.
- `packages/client/src/auth-get-session-envelope.test.ts` — not in `git
diff --name-only`, and green in the run above.

The shared lift *is* changed, deliberately — and the ablation above
shows it is load-bearing for both families, which is the point of there
being one of it.

## Docs

No documentation change is owed, measured rather than assumed. At
`origin/main` `8fa3fe63`:

```
git grep -c 'data\.session' -- content/docs   ->  0 hits   (exit 1)
git grep -c 'data\.user'    -- content/docs   ->  3 hits   (live positive control)
```

The only page that reads fields off these two calls is
`content/docs/permissions/authentication.mdx`, which reads
`result.data.user`, `result.data.token` and `session.data.user` — all
three preserved by this change. No published page tells a reader to
reach for `data.session`, so leaving that half undelivered falsifies no
documentation. ⛔ Nothing under `content/docs/releases/` is touched: that
tree is release-owned.

Clause-②: no

This pulls an implementation back to a contract that is already
declared. No published surface widens, no accepted set widens, and
nothing in `packages/spec` is edited — the triage ruling on this card
says the same in its own words.

## Acceptance notes

Found while measuring, **not** filed and **not** fixed here:

- The `normalizeSessionResponse` docblock stated that the token `login`
puts at `data.token` is the SIGNED `token.signature` form. Measured, it
is the **unsigned** one: the response BODY's `token` and the
`session.token` a following `/get-session` serves are the same string,
while the signed form is what `bearer()` publishes in the
`set-auth-token` **header**. Corrected in this PR — same file, same
docblock, same subject, as the dispatch order directs — and the
corrected reading is now pinned by a case in block ④ so it cannot rot
again. The rule the sentence justified (never synthesize `data.token`
from a session) is unchanged and now rests on the right ground.
- The same docblock said `auth.login` "has carried the same lift … since
long before this card". That was loose — login's inline copy filled
`data` and never `success`, which is this defect. After this PR it is
literally the same lift, and the sentence is rewritten to say so.
- noted, not filed: `login`'s own `if (!res.ok) { … throw … }` block is
unreachable. `ObjectStackClient.fetch` already throws on every non-2xx
before `login` can inspect `res.ok` (observed directly: a
`SELF_REGISTRATION_CLOSED` sign-up threw from `fetch`, never reaching
the caller's branch). Dead code, not a defect — left untouched.
Successor: whoever converges the SDK's two error envelopes (objectstack-ai#3843 is the
line that would reach it).
- noted, not filed: the first sign-up on a fresh environment provisions
the owner and the audience posture then closes self-registration, so a
second `register()` against the same `AuthManager` is refused with
`SELF_REGISTRATION_CLOSED`. Correct behaviour, and a real trap for
anyone writing a driven auth test — recorded in the suite's own
comments. Successor: the next author writing a multi-user driven auth
test in `packages/client`.
- ⛔ objectstack-ai#17238 (the anonymous-caller case) is not addressed here and is out
of scope: different defect, `domain:services` lane, unruled. objectstack-ai#17235
(`data.user.image` served as `null`) is likewise out of scope and stays
pinned as residue.

Authored by an agent session:
https://claude.ai/code/session_01TSf4DV7ziu4V5j73e46b7c


---
_Generated by [Claude Code](https://claude.ai/code)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 17, 2026
…t-purity family (objectstack-ai#17805)

Part of objectstack-ai#17633 — it repairs the one file the 2026-09-12T05:54Z sweep
names, and carries no closing keyword on purpose.
`test-nightly-tiers.yml` states of its green path: "On green this
workflow files nothing, edits nothing and closes nothing", so the card
is closed by a seat reading a later nightly as `success` — a reading no
merge can assert. objectstack-ai#17633 therefore stays open after this lands; the half
left behind is that confirmation.

Clause-②: no

## What was red

`packages/cli/test/json-stdout-purity.e2e.test.ts`, in `describe('the
family this contract has to hold across')`:

```
FAIL  is exactly the set listed here — a new member goes red until it is driven too
AssertionError: expected [ 'meta resync', …(12) ] to deeply equal [ 'meta resync', …(11) ]
+   "migrate account-issuer",
```

That file DISCOVERS its family from the source tree — every command
under `packages/cli/src/commands` that calls `bootSchemaStack(` and
declares `json: Flags.boolean(` — and reconciles the discovered set
against the hand-listed `FAMILY`. `FAMILY` is not a list of names:
`describe.each(Object.keys(FAMILY))` drives every key, and each value is
the extra argv that drives it. So a row is a promise that the member is
actually exercised.

`os migrate account-issuer` landed in `9bd4344e4` (by objectstack-ai#17454) as a new
`--json` face on that seam, without a `FAMILY` row. Discovery found 13
members against 12 listed. It merged green because this file is
`*.e2e.test.*`, which runs only under `OS_TEST_TIERS=nightly` — the
class this card exists to see.

Symbols, not line numbers: `FAMILY` is at `:82`, `discoverFamily()` at
`:129`, the reconciliation at `:209`, `describe.each` at `:248`. All
four are still at the line numbers the card body recorded at 05:54Z, on
the tree this branch was cut from.

## The premise, measured before any edit

The premise to falsify was that `os migrate account-issuer --json`
already SATISFIES the stdout-purity contract and only its `FAMILY` row
is missing. It was driven by hand against the fixture this suite builds,
before the row existed. It holds, on all three halves:

```
$ cd FIXTURE && NO_COLOR=1 OS_DATABASE_URL="file:FIXTURE/migrate-account-issuer.db" \
    tsx packages/cli/bin/run-dev.js migrate account-issuer --json
EXIT=1
stdout (1 line, bare JSON.parse succeeds — keys: error, code):
{"error":"Cannot enumerate sys_account: The database refused to run this query for object 'sys_account'. … Refusing rather than reporting an unread table as clean.","code":"RESOURCE_CONFLICT"}
```

| half of the contract | reading on stdout | reading on stderr |
|:--|:--|:--|
| exactly one JSON document | `JSON.parse` of the whole stream succeeds
| — |
| no kernel-logger record, no `[StandaloneStack]` | 0 matches for each |
— |
| `[StandaloneStack] no compiled artifact` | 0 | 1 |
| `Bootstrap complete` | 0 | 1 |
| `Graceful shutdown complete` | 0 | 1 |

So this is a test-only diff, and the branch the dispatch order reserved
for a purity DEFECT was not taken.

## The fix

One `FAMILY` row, with the argv that drives it — `[]`, the bare form. It
boots because this command takes the `os migrate plan` shape
(`deferSchemaDdl: true` + `readOnlyProbe: true`), so the fixture's
absent sqlite file is opened as an empty in-memory database rather than
brought into existence. No `sys_account` table exists there, so the face
driven is the command's REFUSAL face: the `emitJson(…, 1, { compact:
true })` branch, which is the noisier of its two emit paths — every
driver and kernel diagnostic the failed scan produces is emitted before
it. The clean-report face is unreachable in this fixture by
construction, and the header already states why the fixture stays
uncompiled and minimal.

## `CONFIG_MISS_FAMILY`: decided NO, from the source rather than from
the colour

`migrate account-issuer` does NOT belong to the pre-boot family, and the
overlap assertion stays `['migrate meta']`.

`discoverConfigMissFamily()` takes a command iff it declares `json:
Flags.boolean(` AND imports from `utils/config.js` — directly, or
through a class it extends. `account-issuer.ts` imports
`utils/format.js` and `utils/schema-migrate.js` and extends `Command`;
it never reaches `resolveConfigPath()`, so it has no refusal branch for
that family to drive. Re-running the shipped discovery over this tree
returns the same ten members, `migrate account-issuer` not among them,
and `preBoot` is still length 10.

This is the decision, not its consequence: had it been hand-added to
`CONFIG_MISS_FAMILY`, the `:227` reconciliation would have gone red
precisely because the discovery disagrees — the pin catches a
hand-addition, which is the same evidence read from the other side.

## Verification

All runs in one worktree, through `scripts/pm/os-verify-lock.sh`; the
verdict line quoted is the one the wrapper prints.

1. RED reproduced first, on the tier that shows it, at `8da783206`
before the edit:

```
$ OS_TEST_TIERS=nightly pnpm --filter @objectstack/cli exec vitest run \
    --project integration test/json-stdout-purity.e2e.test.ts
 ❯ test/json-stdout-purity.e2e.test.ts (38 tests | 1 failed) 89131ms
 × is exactly the set listed here — a new member goes red until it is driven too
AssertionError: expected [ 'meta resync', …(12) ] to deeply equal [ 'meta resync', …(11) ]
+   "migrate account-issuer",
 ❯ test/json-stdout-purity.e2e.test.ts:209:30
 Test Files  1 failed (1) · Tests  1 failed | 37 passed (38)
os-verify-lock: VERDICT command-exit 1
```

Byte-for-byte the failure the card records, down to the diff line and
`:209:30`.

2. GREEN after, same command, with the member DRIVEN — three new
per-member cases appear, 38 tests become 41:

```
 ✓ os migrate account-issuer --json > emits ONE JSON document on stdout — a bare JSON.parse, no extraction
 ✓ os migrate account-issuer --json > leaves no kernel-logger record on stdout
 ✓ os migrate account-issuer --json > still shows the operator every boot diagnostic — on stderr
 Test Files  1 passed (1) · Tests  41 passed (41) · Duration 97.75s
os-verify-lock: VERDICT command-exit 0
```

3. A control that can fail — two legs, each mutating this file on disk,
each proving the mutation landed by hash and restoring under a `trap …
EXIT INT TERM` that re-checks the hash against the HEAD blob
(`d30b0f4d06e56ca0ed0243207f7d2e42d6f52031`). No `dist` is involved:
vitest loads this test file from source, so there is no built artifact
for the mutation to fail to reach.

LEG A — the positive control, the driven run's own output and exit code.
A temporary case read the captured run for `migrate account-issuer` and
asserted its payload and its exit status, then was removed:

```
ABLATION-A stdout: {"error":"Cannot enumerate sys_account: … Refusing rather than reporting an unread table as clean.","code":"RESOURCE_CONFLICT"}
ABLATION-A exit code: 1
 ✓ ABLATION-A temporary control > the account-issuer ROW really drove the command: its output and exit code
 Tests  42 passed (42)
```

That payload belongs to no other member of the family — it is this
command's own `sys_account` refusal — so the row is driving the command
it names, not merely sitting in a list.

LEG B — the mutation, a row that drives NOTHING. The row's argv became
`['--drives-nothing']`, which oclif refuses above the command:

```
 × os migrate account-issuer --json > emits ONE JSON document on stdout — a bare JSON.parse, no extraction
 × os migrate account-issuer --json > still shows the operator every boot diagnostic — on stderr
AssertionError: expected 'objectstack: INVOCATION ERROR — Nonex…' to contain '[StandaloneStack] no compiled artifact'
+ objectstack: INVOCATION ERROR — Nonexistent flag: --drives-nothing. The command never ran: nothing was started and nothing is listening.
 Tests  2 failed | 39 passed (41)
```

So a row that drives nothing cannot pass as one that does. Reported as
measured, including the part that is not flattering: the THIRD case,
`leaves no kernel-logger record on stdout`, stayed GREEN through leg B.
It is a pair of negative assertions, and they are vacuously true of an
empty stdout — it binds purity, not existence. The two that do bind
existence are enough for this row, and the trio is unchanged by this PR.

Restore was verified on both legs by hash equality with the HEAD blob
and an empty `git diff HEAD`, not by an exit code.

4. Gate families, derived from the change set rather than listed by hand
— `node scripts/pm/dispatch-gates.mjs --commands` with no paths, then
reconciled with `--ran` carrying each recorded exit code:

```
Run reconciliation — 48 derived, 47 run, 1 NOT-MEASURED, 0 UNRUN.
✓ dispatch-gates --ran: 48 derived famil(ies) accounted for — 47 run, 1 NOT-MEASURED (1 DERIVED from a recorded exit 3).
```

The one NOT MEASURED is `pnpm check:dual-build-cjs-loads`, and it is not
a red: it exits 3 with `PREREQUISITE NOT MET — this gate reads built
output, and some package has no dist/ … ⛔ This is NOT a pass: nothing
was measured`, naming twelve packages outside this card's build closure.
It needs a whole-repo `pnpm build`; CI builds everything and is the
authority on it. `pnpm check:nul-bytes` is in the 47 and green; a
control-character sweep over the edited file (`grep -naP` over the C0
set plus DEL) also returns nothing.

5. `pnpm --filter @objectstack/cli build && pnpm --filter
@objectstack/cli typecheck` — `VERDICT command-exit 0`. Reported
precisely, because the two halves do not cover the same files:
`tsconfig.json` declares `include: ["src"]`, so the `tsc --noEmit` half
does NOT reach `test/`; the half that reaches this diff is
`check:test-typecheck`, which reports `@objectstack/cli's test layer
compiles under packages/cli/tsconfig.test.json`.

6. The pin re-checked against the CURRENT `main`, not only against the
base this branch was cut from. `origin/main` at `310760d22` touches
neither `packages/cli/src/commands` nor `packages/cli/test` since
`8da783206`, and running the shipped discovery over `origin/main`'s own
tree returns exactly the thirteen members `FAMILY` carries after this
PR. So the set is right against the tree this will land on.

7. `node scripts/pm/check-clause2-carriers.mjs --pair 17805` — exit 0:
"the clause-② declaration is readable in the fixed spelling and both
carriers agree, and its diff carries no widening tell."

Not run locally and left to CI, declared rather than implied:
`packages/cli`'s `integration` tier beyond this one file, the repo-wide
`pnpm lint`, and the whole-repo build `check:dual-build-cjs-loads`
needs.

Session: https://claude.ai/code/session_01TSf4DV7ziu4V5j73e46b7c

## Changeset

**None, deliberately.** Measured rather than assumed: `@objectstack/cli`
declares `files: ["dist", "README.md", "CHANGELOG.md"]`, and after `pnpm
--filter @objectstack/cli build`, a string unique to this diff (`the
noisier of its two`) has **0** hits across all three of those paths,
while the positive control on the same pass — `Pre-flight the retirement
of sys_account.issuer`, the command description that really does ship —
has **1**, in `packages/cli/dist/commands/migrate/account-issuer.js`.
The matcher fires, so the zero is a reading. `packages/cli/test/**` is
not under any published path and `dist/` carries no compiled test file.
Nothing published moves, so this is the `skip-changeset` case rather
than a missing one.

## Acceptance notes

- Measured and left alone: `leaves no kernel-logger record on stdout` is
vacuously green against a run that never happened (leg B above). It is
correct for what it asserts — purity, not existence — and the sibling
cases in the same trio cover existence. Noted, not filed: it is a
property of the whole `describe.each` block, no in-flight PR holds this
file, and changing it is a pin redesign rather than this card.
- Reading, no claim attached: the driven refusal payload carries
`"code":"RESOURCE_CONFLICT"` for a missing `sys_account` table, via
`errorCodeFields()` over the sql driver's deliberately unattributable
`DATABASE_ERROR`. Recorded because it is what the driven run prints; no
contract text and no repro of harm was gathered, so nothing is filed and
no follow-up owner is claimed.
- `CONFIG_MISS_FAMILY` and the `:227`/`:236` overlap assertion were
examined and deliberately left unchanged — the reasoning is in its own
section above, from the command's imports and the shipped discovery, not
from which answer happened to be green.

---
_Generated by [Claude Code](https://claude.ai/code)_

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 17, 2026
…rocess, and the canonical origin follows the listener (objectstack-ai#17725)

Fixes objectstack-ai#16804

**objectstack dev** `--cert` &lt;path&gt; `--key` &lt;path&gt;
terminates TLS in the dev process itself, and every origin the boot
advertises follows the listener. This is director-seat ruling
[`5617187807`](objectstack-ai#16804 (comment)),
batch objectstack-ai#111 item 1, option **N (narrow)** — parts (1)+(2) of the card's
Ask in exactly that shape.

## ⛔ No CA generation, and no trust-store prose — 不生成 CA、不写信任库指引

The ruling, quoted verbatim (⛔ not paraphrased — 引用中文裁决时保持原文):

> **裁定**:objectstack dev --cert &lt;path&gt; --key
&lt;path&gt;:开发者自带证书,dev 进程内终止 TLS;同笔做 (2):规范 origin 自动为
`https://localhost:<port>`,两个 `.well-known/*` 文档随之广播 https,`OS_AUTH_URL`
只作覆盖(`resolveAuthBaseUrl` 的硬编码 `http://` 回落尾巴按 listener 协议派生)。⛔ 不生成自签
CA;⛔ 不打印、不文档化任何「把 CA 装进系统信任库」的指引——信任库是开发者自己的事。

So, in as many words: **this PR generates no certificate and no CA, and
it writes no instruction anywhere — not in code, not in `--help`, not in
a doc page, and not as a suggestion in this body — for installing a
certificate into a system trust store.** The trust store is the
developer's own business. The developer brings the certificate; the
feature's whole job is to *use* it. Option **F** (generated CA + trust
instructions) is refused on the ruling's security-statement ground;
option **X** (document the proxy recipe) is not delivered.

That refusal is **asserted, not merely promised**:
`dev-tls-contract.test.ts`'s last `describe` reads both flag
descriptions and the module's own source for `trust store` / `keychain`
/ `certutil` / `add-trusted-cert` / `self-signed` / `generated CA` and
for `node:crypto`, `generateKeyPairSync`, `X509Certificate`, and an
ANTI-VACUITY case proves the same scan finds words that *are* there — so
a future edit adding that prose cannot pass by having nothing to read.

## The confidence gap the ruling handed the implementing seat — MEASURED

> 置信缺口留给实施席先测:Hono 适配层接 Node TLS 的成本(`packages/cli/src` 今天零处 TLS 代码),超出
M 级停手回报。

**Reading: XS.** `@hono/node-server@2.1.1` takes the listener factory as
an *option*. Its exported `Options` type is a union whose https arm is,
verbatim from `node_modules/@hono/node-server/dist/index.d.mts`:

```ts
type createHttpsOptions = {
  serverOptions?: ServerOptions$2;              // node:https ServerOptions
  createServer?: typeof createServer$2;         // node:https createServer
};
```

So terminating TLS needs **no bridging code at all** — the same `fetch`
handler, the same route table, the same graceful drain, one different
server factory. Well under an M; no stop was warranted.

## Premise check — all four re-derived on this branch's base `6fa2a8ae`,
all four hold

| # | premise | re-derived here |
|:--|:--|:--|
| 1 | `resolveAuthBaseUrl` has a hardcoded `http://` fallback tail | ✓
`packages/cli/src/commands/serve.ts:5634` declared it; `:5637` was ``??
`http://localhost:${port}` `` |
| 2 | `packages/cli/src` contains **zero** TLS code | ✓
`https.createServer` · `createSecureServer` · `node:tls` · `node:https`
→ **0** each. Positive control from the same tree: `createServer` → 3
hits (`commands/serve.ts:255` — a `net.createServer()` port probe — plus
`utils/port-contract.ts:52` and `serve-port-validation.test.ts:96`, both
in prose), `from 'node:` → 313 |
| 3 | `dev` declares no `--cert` / `--key` | ✓ zero on `dev.ts` +
`serve.ts`; `dev`'s flags are at `commands/dev.ts:128` (`static override
flags`). The only `cert`-shaped hits in `serve.ts` were the word
"certainly" at `:410` and `/.well-known/*` prose at `:5036` |
| 4 | part (3) is already fixed by objectstack-ai#16734 / PR objectstack-ai#16812 | ✓
`printMcpConnectHint` already resolves through `resolveAuthBaseUrl`;
**not** re-implemented here |

`packages/plugins/plugin-hono-server/src/adapter.ts` likewise had
**zero** `https` hits, against a same-file positive control of 8 for
`key` — so the TLS arm is genuinely new rather than a second copy of
one.

## What follows the listener, and what deliberately does not

`resolveAuthBaseUrl(port, boundProtocol)` — **only the built-in default
tail moves.** That tail is the one link in the chain nobody configured:
it is the process describing its own socket, and once TLS terminates
in-process http://localhost:&lt;port&gt; is an address no client can
reach.

⛔ Every **configured** link keeps winning — `OS_AUTH_URL`, the legacy
`BETTER_AUTH_URL`, `OS_BASE_URL` — **an `http://` value included.** They
answer a different question: where the deployment is *reached*, which
behind a proxy or a tunnel has no relation to what this process bound. A
default has no standing to overrule an operator's deliberate statement
about a different hop, and a "helpful" scheme upgrade there would be a
bug wearing a feature's clothes.

Because the whole boot already routed through that one resolver, the
three acceptance surfaces follow with no second reader: the two
`/.well-known/*` documents (`AuthManager.getCanonicalOrigin()` ←
`AuthPlugin({ baseUrl })` ← `resolveAuthBaseUrl`), the CSRF allow-list,
the ready banner's `API:` / `MCP:` rows, and the `🤖 MCP server` block.

`publishBoundPort(..., boundProtocol)` is the **socket's own** address
rather than the canonical origin, so it is ⛔ *not*
`OS_AUTH_URL`-overridable: both of its consumers *open* that url — the
runtime state file is what an external supervisor dials, the IPC message
is what the `os dev` parent learns the server from — and a hardcoded
`http://` under a TLS listener hands both a machine-readable address
that answers a handshake error. This change is what makes that surface
false, so it ships with it.

## Acceptance — driven on a real boot of `examples/app-todo`, bytes
pasted

### ① `--cert`/`--key`, `OS_AUTH_URL` unset — all three surfaces give
https

`node packages/cli/bin/run.js dev --fresh -p 38471 --cert CERTPATH --key
KEYPATH`
(CERTPATH / KEYPATH are the two real file paths; spelled as words
because a
bracketed placeholder does not survive a GitHub body write):

```text
  ➜  API:       https://localhost:38471/
  ➜  MCP:       https://localhost:38471/api/v1/mcp
      connect an AI client (Claude Code, Cursor, …) · skill: https://localhost:38471/api/v1/mcp/skill

  🤖 MCP server — connect a coding agent:
     Endpoint  https://localhost:38471/api/v1/mcp
     Skill     https://localhost:38471/api/v1/mcp/skill
     Connect   claude mcp add --transport http app-todo https://localhost:38471/api/v1/mcp
     Disable   OS_MCP_SERVER_ENABLED=false
```

`GET /.well-known/oauth-protected-resource` over that real TLS socket:

```json
{
  "resource": "https://localhost:38471/api/v1/mcp",
  "authorization_servers": ["https://localhost:38471/api/v1/auth"],
  "scopes_supported": ["data:read", "data:write", "actions:execute", "offline_access"],
  "bearer_methods_supported": ["header"],
  "resource_name": "ObjectStack MCP"
}
```

`GET /.well-known/oauth-authorization-server` (url-valued fields):

```json
{
  "issuer": "https://localhost:38471/api/v1/auth",
  "authorization_endpoint": "https://localhost:38471/api/v1/auth/oauth2/authorize",
  "token_endpoint": "https://localhost:38471/api/v1/auth/oauth2/token",
  "jwks_uri": "https://localhost:38471/api/v1/auth/jwks",
  "registration_endpoint": "https://localhost:38471/api/v1/auth/oauth2/register"
}
```

And the listener really is TLS — plain http on that same port, with its
exit code captured **before** any pipe:

```text
$ curl -sS --noproxy '*' -m 10 http://localhost:38471/.well-known/oauth-protected-resource > log 2>&1; echo "EXIT=$?"
EXIT=52
curl: (52) Empty reply from server
```

The runtime state file names the socket, not a guess:
`{"pid":27532,"port":38471,"url":"https://localhost:38471",...}`.

### ② Without the flags — identical to today

Same command, flags removed, port 38472:

```text
  🤖 MCP server — connect a coding agent:
     Endpoint  http://localhost:38472/api/v1/mcp
     Skill     http://localhost:38472/api/v1/mcp/skill
     Connect   claude mcp add --transport http app-todo http://localhost:38472/api/v1/mcp
  ➜  API:       http://localhost:38472/
  ➜  MCP:       http://localhost:38472/api/v1/mcp
```
```json
{ "resource": "http://localhost:38472/api/v1/mcp",
  "authorization_servers": ["http://localhost:38472/api/v1/auth"] }
```
state file: `{"port":38472,"url":"http://localhost:38472",...}`

⭐ **Pinned as an ablation, not a claim** — three legs, each requiring
the omitted argument and an explicit `http` to be *identical* while both
differ from `https`:

- `dev-mcp-connect-hint-origin.test.ts` — `bootWithoutProtocolArg()`
reproduces this file's pre-change call expression character for
character and drives it beside today's call; the whole captured boot
buffer (banner + hint, `console.error` and `console.log` in call order)
must match byte for byte, over a plain port, dev's auto-shifted port and
an ephemeral one.
- `serve-auth-base-url-diagnostic.test.ts` — `resolveAuthBaseUrl(port)`
must equal `resolveAuthBaseUrl(port, 'http')` and differ from
`resolveAuthBaseUrl(port, 'https')`.
- `serve-bound-port-publication.test.ts` — the same, for the state-file
payload and the IPC message.
- `adapter-tls-listener.test.ts` — the http and https listeners are
constructed identically but for the fourth argument, and each
**refuses** the other's protocol, so neither leg can pass by being
broken in a convenient direction.

### ③ `OS_AUTH_URL` still wins

`OS_AUTH_URL=https://tunnel.example.com` **with** both TLS flags, port
38473:

```text
  ➜  API:       https://tunnel.example.com/
  ➜  MCP:       https://tunnel.example.com/api/v1/mcp
     Endpoint  https://tunnel.example.com/api/v1/mcp
     Connect   claude mcp add --transport http app-todo https://tunnel.example.com/api/v1/mcp
```
```json
{ "resource": "https://tunnel.example.com/api/v1/mcp",
  "authorization_servers": ["https://tunnel.example.com/api/v1/auth"] }
```

…while the state file still names the socket:
`{"port":38473,"url":"https://localhost:38473",...}` — the canonical
origin and the bound address answering their own questions, as designed.
The awkward direction is pinned too: an `http://` `OS_AUTH_URL` under a
TLS listener is **not** upgraded.

### Refusals — loud, and never degraded to plain http

```text
$ os dev --cert CERTPATH                                  # EXIT=1
  ✗ --cert was given without --key.
    TLS needs both halves: --cert CERT-PATH-PLACEHOLDER --key KEY-PATH-PLACEHOLDER.
    Drop both to serve plain http on this port.

$ os dev --cert /no/such/cert.pem --key /no/such/key.pem  # EXIT=1
  ✗ --cert could not be read: "/no/such/cert.pem"
    ENOENT: no such file or directory, open '/no/such/cert.pem'
    The path is resolved relative to the current working directory.
```

⚠️ In the first block the notice's two bracketed placeholders are
rendered here as
CERT-PATH-PLACEHOLDER / KEY-PATH-PLACEHOLDER. The bytes the CLI actually
prints are
angle-bracketed (`path to the certificate` and `path to its private key`
inside angle
brackets) and are pinned verbatim in `dev-tls-contract.test.ts`; a
bracketed span does
not survive a GitHub body write, so it is spelled out rather than
silently eaten.

⛔ There is deliberately no path from either refusal back to an http
listener: a developer who typed `--cert` asked for TLS, and answering
with the other protocol would surface first as a client-side handshake
error naming neither the flag nor the file. Prefer failing to falling
back.

## Shape of the change

`packages/cli/src/utils/dev-tls-contract.ts` is the **one** reader of
the pair, shared by `dev` and the `serve` child it spawns — the same
judgement as `port-contract.ts`, for the same reason: before it, a value
typed at `dev` would have been refused one process later under the name
of the channel it arrived on. `dev` forwards the **paths**, never the
bytes, so exactly one process reads the file and exactly one owns that
refusal — and the parent's `boundProtocol` comes from the same
`resolveDevTlsIntent` call that built the child's argv, so the scheme
the hint prints and the scheme the child binds cannot part company
(pinned: exactly one `resolveDevTlsIntent(` call in `dev.ts`).

`HonoPluginOptions.tls` takes PEM **bytes**, ⛔ not paths: whoever hands
the adapter TLS material is the layer that knows why it has it and can
name the flag the operator typed. A transport adapter taking paths would
own a second reader of the same file and would have to invent a refusal
from a filename.

## Scope — declared growth, and two fences confirmed clear

The dispatch's declared face was `commands/dev.ts`, `commands/serve.ts`
and their tests. The delivered diff **grew** into
`packages/plugins/plugin-hono-server` (`adapter.ts`, `hono-plugin.ts`,
one new test) and `packages/cli/src/utils`, because `serve()` — the only
place a listener is created — lives in the adapter, and putting TLS
anywhere else would have meant a second listener owner (Route & surface
ownership §1). Contract-first: the option belongs at the producer.

Checked rather than assumed: **no open PR holds any file in this diff.**
All 9 open PRs' file lists were read; PR objectstack-ai#17454's cli files are
`index.ts`, `commands/init.ts`,
`commands/migrate/{account-issuer,apply}.ts` — disjoint from every path
here — and no open PR touches `plugin-hono-server` at all. ⛔ No
`packages/spec` edit: `HonoPluginOptions` is declared locally in
`hono-plugin.ts`, so the new option needed none (`packages/spec` was
read only). ⛔ `content/docs/releases/` untouched.

`Clause-②` re-derived from the **delivered** diff: still **yes** — two
new public CLI flags on two commands, a new exported module, a widened
exported `resolveAuthBaseUrl` / `publishBoundPort` signature (both
additive defaults), and a new public `HonoPluginOptions.tls`.

## Two pins this change moved, and why they are updated rather than
deleted

`serve-bound-port-publication.test.ts` held three source-text pins that
went red on the first run — exactly their job. Each is updated to the
new spelling with its intent intact: the banner pin still asserts the
origin comes from `boundPort` (not `port`), the publish-site pin still
asserts the seam is handed the resolved bound port, and the positive
control for the `not.toContain` negatives still asserts `port` reaches
the transport under its own name — now by reading the construction site
rather than one formatting of it.

## The acceptance-2 pins CAN fail — proven by mutation, not by
inspection

An ablation leg only means something if it goes red when the thing it
guards moves. Driven
from the committed state, on disk, with the restore in a `trap`:

**Mutation.** Both `boundProtocol: ListenerProtocol = 'http'` defaults —
`resolveAuthBaseUrl`'s
and `publishBoundPort`'s; there are exactly two, and the script refuses
to proceed on any
other count — flipped to `'https'`, which is precisely the "derived
protocol leaked into the
default" regression.

```text
HEAD blob: 8afd9f4
before: old-spelling=2  new-spelling=0
after:  old-spelling=0  new-spelling=2      <- the edit REACHED THE DISK
mutated blob: 2ae1e42c1400fcb736763b5e18243554198f685a

MUTATED_EXIT=1      Test Files  2 failed (2)      Tests  5 failed | 48 passed (53)

restored blob: 8afd9f4   <- byte-identical to HEAD
git diff HEAD: empty;  git status --porcelain: []

RESTORED_EXIT=0     Test Files  2 passed (2)      Tests  53 passed (53)
```

⚠️ Observed direction: **turns red** — the ordinary one. The five that
failed:

```text
× resolveAuthBaseUrl — precedence (pre-existing behaviour, unchanged)
    > falls back to http://localhost:<port> when no variable is set
× resolveAuthBaseUrl — the listener protocol reaches the TAIL and stops there
    > ABLATION: the omitted argument and an explicit `http` are the same call
× without the flags the output is byte-for-byte what it was
    > the no-flag boot equals the boot that never passes a protocol at all
    > the two legs also agree on an auto-shifted port and an ephemeral one
    > and the legs DISCRIMINATE — the https leg differs from both
```

⭐ The first of those five is **not one of this PR's tests** — it is the
pre-existing objectstack-ai#10202
pin, which has guarded that fallback since before `--cert` existed. A
mutation that reddens a
pin written years before the feature is the cleanest available evidence
that acceptance 2 is
guarded by the tree and not merely by this PR's own assertions.

Discipline notes, since each is a way this measurement could have been
void: the restore is
`git checkout HEAD -- <absolute path>` (⛔ never bare `git checkout --`,
which restores the
mutation back out of the index); it is verified by `git diff HEAD` being
empty and by blob
equality, ⛔ never by an exit code; the `trap … EXIT INT TERM` uses an
absolute path resolved
from `git rev-parse --show-toplevel`; and an empty or unresolvable blob
hash is treated as a
FAILURE rather than as "nothing to compare". No mutated file survives —
the final tree is
clean at `03ba3112`.

## Gates — every derived family measured, at `03ba3112`

The set was **derived from the delivered diff**, not taken from the
dispatch's list:
`node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack`, re-derived
after the docs commit (13 paths → **95** commands, 30 of them families
only the two
`content/docs/` paths schedule). Reconciled back:

```text
Run reconciliation — 95 derived, 95 run, 0 NOT-MEASURED, 0 UNRUN.
```

**95 / 95 green.** Four needed a second pass, and each one is worth
naming:

| family | first pass | why, and what closed it |
|:--|:--|:--|
| `check:nul-bytes` | **exit 1 — a real finding** | A scripted edit had
materialised `\u001b` into a raw `0x1b` byte in
`dev-tls-contract.test.ts:209`, in the very assertion that is *about*
that byte — the exact slip the gate's header says every occurrence in
this repo came from. Fixed to the escape text in `c6500241`; now `OK
(scanned 8438 text file(s) … no raw ASCII control bytes)`, plus a `grep
-naP` self-scan over every changed file returning empty |
| `check:dual-build-cjs-loads` | exit 3 | `PREREQUISITE NOT MET` —
unbuilt sibling packages, ⛔ not a pass and ⛔ not a red. Re-run after the
closure builds: `✓ 104 published require entry point(s) across 67
package(s) load; 620 emitted CommonJS file(s) parse` |
| `check:i18n-coverage` | exit 3 | Same class — `os lint` could not load
`app-showcase`'s config against an unbuilt `connector-mcp`, so *nothing
was compared*. Re-run: `OK (13 config(s), 621 baselined untranslated
string(s), none new)` |
| `check:type-check-debt` | exit 3 | The `--re-measure` leg OOM'd —
under **my own** `NODE_OPTIONS=--max-old-space-size=4096`, which is
*below* the 6144 MB CI-shaped ceiling the gate pins for itself. It
refused to record 0 rather than lying. Re-run at 8192: `OK — 5 ledger
entr(ies) re-measured in 98.2s, 55 raw tsc error(s) total, none above
its recorded number` |

⚠️ An `exit 3` from any of those three is `PREREQUISITE NOT MET` — **NOT
MEASURED**, in neither direction — so none of them was reported green
until it had actually run.

**`pnpm lint` — the full union, not a narrowing.** The lane adds it and
`dispatch-gates.mjs` does not name it. It completed over the whole repo
at the final commit `03ba3112` (clean tree, `git status --porcelain`
empty):

```text
node --stack-size=4000 node_modules/eslint/bin/eslint.js . --no-inline-config --format json
→ 6638 files linted · 0 errors · 0 warnings   (exit 0)
```

The 10 changed `.ts` files also lint clean on their own (`--format json`
→ 10 files, 0/0). Since the union ran, no invariance argument is owed;
for the record, this repo's single `eslint.config.mjs` enables **no**
type-aware linting (zero `parserOptions.project` / `projectService`
matches — the config says so itself at `:328`), so a diff here cannot
move an untouched file's verdict anyway.

**Build / typecheck / tests**, all through
`scripts/pm/os-verify-lock.sh` (one lock per container):

| run | verdict |
|:--|:--|
| `pnpm --filter '@objectstack/plugin-hono-server^...' --filter
'@objectstack/cli^...' build` | `VERDICT command-exit 0` (held 453s) |
| the two packages' own `build` + `typecheck` | `VERDICT command-exit 0`
|
| `plugin-hono-server` — `adapter-tls-listener` + `adapter-drain` | `2
passed` files, **8 passed** tests |
| `cli --project unit` — the four pin files | `4 passed` files, **102
passed** tests |

`packages/cli`'s `integration` tier is **declared to CI**: no path in
this diff is an integration-tier file, a `bin/` entry or
`test/helpers/serve-process.ts`, so `--project unit` is what is owed
locally.

## Acceptance notes

- `check:nul-bytes` caught a real defect in this PR before it was
pushed: a Python-driven edit materialised `�` into a raw `0x1b` byte in
`dev-tls-contract.test.ts`. Fixed to the escape text; gate re-run green
over 8438 files, plus a `grep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]'`
self-scan over every changed file returning empty.
- **noted, not filed** — `publishBoundPort`'s url is
`localhost`-hardcoded on the host half as well as the scheme half. Under
a non-loopback bind that host is already wrong today, independently of
TLS; this change touches only the scheme, and the host is out of this
card's scope. Carrier: whoever next changes the bind host — no PR or
card currently in flight names it.
- **noted, not filed** — `AuthManager.getCanonicalOrigin()`'s own
fallback is `'http://localhost:3000'` (`auth-manager.ts:6088`), reached
only when no `baseUrl` is configured at all. Every `os serve` / `os dev`
path passes one, so this branch is unreachable from the CLI and no TLS
boot can hit it. Not a defect from any door this card opens. Carrier:
whoever composes an `AuthPlugin` without a `baseUrl`.

---

## 维护者速读(草稿)

**改了什么。** `objectstack dev --cert <证书> --key <私钥>` 两个新 flag:开发者自带证书,dev
进程自己终止 TLS。给了这两个 flag 之后,这次启动对外宣告的所有地址一律变成 `https://localhost:<端口>`——两个
`/.well-known/*` 发现文档、CSRF 白名单、就绪横幅的 `API:`/`MCP:` 行、`🤖 MCP server`
连接提示,以及外部监管进程会去拨的 runtime 状态文件。不给 flag 时逐字节和今天一样。

**为什么改。** 桌面端 MCP 客户端拒绝对非 https 地址发起 OAuth
登录,所以产品页面承诺的「交互式客户端自动弹浏览器登录」在本地开发服务器上根本演示不了。以前唯一的走法是镜头外手搭一页 openssl +
反向代理再手设 `OS_AUTH_URL`——每次演示、每次录屏、每次排查都要重来一遍。这一笔把那页准备工作删掉。

**风险与代价(含回滚)。** ⛔ 不生成任何证书或 CA,⛔ 也不在任何地方(代码、`--help`、文档、本 PR 正文)写「把 CA
装进系统信任库」的指引——信任库仍然是开发者自己的事,这条由测试反向断言把守,将来有人加这句话会红。dev 进程在给了 flag
时持有一份私钥,这是自带证书方案本来就有的性质,不新增。风险面很窄:不给 flag 时代码路径与今天完全相同,已由四处消融测试钉住。回滚 =
revert 本 PR,无数据迁移、无配置残留、无已发布键退役。已配置的 `OS_AUTH_URL` 一律优先(连 `http://`
的值也不被「升级」),所以任何现有部署的行为不动。

**席位意见。** _(留空,待维护者定稿)_

**你要做的。** 这是新公开 CLI 面(`Clause-②: yes`),已挂
`needs:contract-review`。请确认两点:① 两个 flag 的名字与描述文案;② 「只有回落尾巴跟随
listener、所有已配置值一律优先」这条边界是你要的。确认后按常规合并即可。

This pull request was authored by Claude Code in session
`session_01TSf4DV7ziu4V5j73e46b7c`; that sentence is the durable
attribution, kept
in prose because a PR body's footer block is not reliably preserved by
the platform.


---
_Generated by [Claude Code](https://claude.ai/code)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Adopt better-auth's account-issuer rollback: drop sys_account.issuer, retire the backfill, raise the family to 1.7.3

1 participant